Remix on Cloudflare WorkersからDurable Objectsを使う
はじめに
こんにちは、CX事業本部MAD事業部の森茂です。
RemixをCloudflare WorkersでModule Workerへ移行する記事を紹介させていただきましたが、今回は引き続きCloudflare WorkersにデプロイしたRemixアプリケーションからDurable Objectsを扱う方法について紹介させていただきます。
Durable Objectsについて
定義が非常に難しくひとことで言いまとめることは難しそうですが、Durable Objectsはエッジ上で展開され、強い整合性を持つKey-Value型のオブジェクトストレージとして利用できるクラスインスタンスとも言えるでしょう。それぞれのDurable Objectsは一意のIDを持ち、Workerからはバインディングされた複数のDurable Objectsを利用できます。強い整合性を持つため単にストレージとしてでなく、アトミックな動作が必要なアプリケーションや、チャットやホワイトボードなどWebSocketを利用したリアルタイムコラボレーションツールのストレージとして活用できます。
料金体系(2022年6月現在)
Durable ObjectsはWorkers有料プランでのみ利用可能です。無料枠はかなり大きくリアルタイムでよほど大量の情報をやり取りするアプリケーションでなければかなり安価に収めることができそうです。
Workers有料プランのみ | |
---|---|
リクエスト | 100万回、超過分は$0.15/100万回 |
期間 | 400,000GB-s、超過分は$12.50/100万・GB-s |
読み込み | 100万回、超過分は$0.20/100万回 |
書き込み | 100万回、超過分は$1.00/100万回 |
削除 | 100万回、超過分は$1.00/100万回 |
ストレージ | 1GB、超過分は$0.20/GB・月 |
1つのDurable ObjectがWorkerから150万回呼び出され、1か月で100万秒動作したとすると、1か月の推定コストは以下のようになります。(Pricingより抜粋。読み書き削除・ストレージの利用回数は除く。)
合計金額: 約$0.08 + Workers最低料金 $5/月 = 約$5.08
- (150万回 - 無料枠100万回) x $0.15/100万回 = $0.075
- 1,000,000秒 x 128MB/1GB = 128,000GB-s(Durable Objectsの割当メモリは128MB)
- 128,000GB-sは無料枠400,000GB-s未満
Pricing · Cloudflare Workers docs
Durable Objectsの詳細については下記ドキュメントも参照ください。
Remix on Cloudflare WorkersからDurable Objectsを利用する
Cloudflare WorkersからDurable Objectsを利用するにはWorkerをModule Worker形式で記載する必要があります。
Remixアプリケーションの準備
Remixの標準テンプレートではService Worker形式となるため、下記記事で用意したModule Worker形式のボイラープレートを利用して構築します。
$ npx create-remix@latest --template himorishige/remix-cloudflare-workers-module-worker-boilerplate
Durable Objectsの設定
Durable Objectsを利用するには少々贅沢ですが、Durable ObjectsをTodoを管理するデータストレージとしてアプリケーションを構築してみます。Durable ObjectsはWorkers KVやR2とは違いWrangler CLIではなくwrangler.toml
へ用意したDurable Objectsを記載する形となります。
今回はWorkerのEnvironmentをdev
環境として、またDurable ObjectsはTasksDurableObject
として追記します。
#... [env.dev.durable_objects] bindings = [ {name = "TASKS", class_name = "TasksDurableObject"}, ] [[migrations]] new_classes = ["TasksDurableObject"] tag = "v1"
Durable Objectsの利用にはbindingsの設定の他に作成したクラスインスタンスを登録するmigrations
という部分の記載が必要となります。
TasksDurableObjectの作成
wrangler.toml
に登録したDurable ObjectsTasksDurableObject
を作成します。Durable Objectsは下記のようなクラス構文がベースとなります。ただクラスの中は他のWorkerと同様にfetch
を利用した構文となっています。
export class DurableObject { constructor(state, env) { // 初期化処理など } async fetch(request) { // 利用するロジックなど } }
利用できる構文やAPIについては下記ドキュメントも参照ください。
constructor
からはState
とEnv
を受け取り利用できます。State
にはDurable Objectsの状態が、Env
からは他のDurable ObjectsやWorkers KVなどバインディングされたサービスや環境変数が利用できます。
今回は下記のような構成でAPIとして動作する仕組みを用意します。
pathname | method | 動作 |
---|---|---|
/latest | GET | タスクの一覧を取得(最新100件) |
/task | POST | タスクを登録 |
/task?id=taskId | GET | taskIdのタスクを取得 |
/task?id=taskId | DELETE | taskIdのタスクを削除 |
export interface Task { id: string; title: string; timestamp: number; isCompleted: boolean; } export default class TasksDurableObject { // state、envを受け取り利用が可能 constructor(private state: DurableObjectState) { this.state = state; } async fetch(request: Request) { const url = new URL(request.url); switch (url.pathname) { // GET /latest タスクの一覧を取得 case '/latest': { const data = await this.state.storage.list<Task>({ reverse: true, limit: 100, }); const tasks = [...data.values()]; return new Response(JSON.stringify(tasks)); } case '/task': { // GET /task?id=taskId タスクを取得 if (request.method === 'GET') { const params = new URLSearchParams(url.search); const taskId = params.get('id'); const task = await this.state.storage.get<Task>(taskId!); return new Response(JSON.stringify(task)); } // POST /task タスクを登録 if (request.method === 'POST') { const params = await request.json<Pick<Task, 'title'>>(); const taskData: Task = { id: Date.now().toString(), title: params.title || 'no title', timestamp: Date.now(), isCompleted: false, }; await this.state.storage.put(taskData.id, taskData); return new Response(JSON.stringify(taskData), { status: 200 }); } // DELETE /task?id=taskId タスクを削除 if (request.method === 'DELETE') { const params = await request.json<Pick<Task, 'id'>>(); const response = await this.state.storage.delete(params.id); return new Response( JSON.stringify({ message: response ? 'ok' : 'failed' }), { status: 200 }, ); } } default: return new Response('Not found', { status: 404 }); } } }
いくつかDurable ObjectsのAPIはありますが、ほとんどがWeb APIを利用することになり書き方は自由です。Node.jsなどJavaScript/TypeScriptでAPIを作成したことがあればほぼ同様のイメージで作成することができるかと思います。
Durable Objectsが作成できたところでWorkerのエントリーファイルにもtasks-do.ts
を追記します。
//... export { default as TasksDurableObject } from './tasks-do'; //...
RemixからDurable Objectsを利用する
作成したTasksDurableObject
をRemix側から利用します。トップページでタスクの新規登録フォームとタスクの一覧を表示し、詳細ページではタスクの情報と削除ができる動きを組み込んでいきます。
url | ファイル | 用途 |
---|---|---|
http://localhost:8787/ | app/routes/index.tsx | タスク一覧 |
http://localhost:8787/task/[$taskId] | app/routes/task.$taskId.tsx | タスク詳細 |
少し長いですが2ページのみのためソースコードを全文掲載しています。違和感を感じる部分としてはWorker内ではドメイン、ホスト名がないため指定は不要という点かもしれません。今回は公式ドキュメントにあわせてhttps://.../
を利用しています。
import { redirect } from '@remix-run/cloudflare'; import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare'; import { json } from '@remix-run/cloudflare'; import { Form, Link, useLoaderData } from '@remix-run/react'; import type { Task } from 'worker/tasks-do'; type LoaderData = { tasks: Task[]; }; export const action: ActionFunction = async ({ context: { env }, request }) => { const formData = await request.formData(); const taskTitle = formData.get('taskTitle') || ''; if (!taskTitle) { throw json({ error: 'Task title is required', status: 400, }); } // tasksという一意のIDのDurable Objectsを取得 const tasksDo = env.TASKS.get(env.TASKS.idFromName('tasks')); // /taskにPOSTする Worker内ではドメイン、ホスト名がないため指定は不要 const response = await tasksDo.fetch('https://.../task', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ title: taskTitle, }), }); if (response.status !== 200) { throw json({ error: 'Failed to add task', status: response.status, }); } return redirect('/'); }; export const loader: LoaderFunction = async ({ context: { env } }) => { // tasksという一意のIDのDurable Objectsを取得 const tasksDo = env.TASKS.get(env.TASKS.idFromName('tasks')); // /taskにPOSTする Worker内ではドメイン、ホスト名がないため指定は不要 const response = await tasksDo.fetch('https://.../latest'); if (response.status !== 200) { throw new Error(`Failed to fetch task list: ${response.status}`); } const tasks = await response.json<Task[]>(); return json({ tasks }); }; export default function Index() { const { tasks } = useLoaderData() as LoaderData; return ( <div> <h1> <Link to="/">Welcome to Remix</Link> </h1> <div> <Form replace method="post"> <input type="text" name="taskTitle" placeholder="Task title" /> <button type="submit">Add task</button> </Form> </div> <ul> {tasks.map((task) => ( <li key={task.id}> <Link to={`/task/${task.id}`}>{task.title}</Link> </li> ))} </ul> </div> ); }
Durable Objectsへのアクセス部分のみ特殊な書き方になりますが、ほかは通常のRemixアプリケーションと同等です。続けて詳細画面を作成します。
import { redirect } from '@remix-run/cloudflare'; import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare'; import { json } from '@remix-run/cloudflare'; import { Form, Link, useLoaderData } from '@remix-run/react'; import type { Task } from 'worker/tasks-do'; type LoaderData = { task: Task; }; export const action: ActionFunction = async ({ context: { env }, params }) => { const taskId = params.taskId; const tasksDo = env.TASKS.get(env.TASKS.idFromName('tasks')); const response = await tasksDo.fetch('https://.../task', { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: taskId, }), }); if (response.status !== 200) { throw json({ error: 'Failed to delete task', status: response.status, }); } return redirect('/'); }; export const loader: LoaderFunction = async ({ context: { env }, params }) => { const taskId = params.taskId; const tasksDo = env.TASKS.get(env.TASKS.idFromName('tasks')); const response = await tasksDo.fetch(`https://.../task?id=${taskId}`); if (response.status !== 200) { throw new Error(`Failed to fetch task detail: ${response.status}`); } const task = await response.json<Task>(); return json({ task }); }; export default function Index() { const { task } = useLoaderData() as LoaderData; return ( <div> <h1> <Link to="/">Welcome to Remix</Link> </h1> <h2>{task.title}</h2> <p>{new Date(task.timestamp).toISOString()}</p> <Form method="post"> <button type="submit">Delete</button> </Form> </div> ); }
動作の確認
トップページと詳細ページができあがったところで動作を確認していきます。Durable Objectsはwrangler dev
のローカルモードでも確認が可能です。なお、Wranglerのdevサーバーは起動ごとにDurable Objectsのstateはリセットされます。
$ yarn dev //... [0] ⛅️ wrangler 2.0.8 [0] ----------------------------------------------------- [0] Your worker has access to the following bindings: [0] - Durable Objects: [0] - TASKS: TasksDurableObject [0] - Vars: [0] - SESSION_SECRET: "should-be-secure-in-prod" [0] ⎔ Starting a local server... //...
開発サーバー起動時にもDurable Objectsが認識されているのが確認できます。
ブラウザでhttp://localhost:8787
を開き登録、削除の動きも見てみます。
登録、削除ともに動作が確認できました。今回はtasks
という共通のidでDurable Objectsにアクセスしています、この部分をカテゴリーやユーザーのIDごとに発行することで別のDurable Objectsとして利用できるので、カウンターのような誰でも共通した状態を利用したい場合とユーザーごとに状態を分けて利用したい場合など用途にあわせてDurable Objectsを用意するのがよいでしょう。
さいごに
今回はCloudflare Workers上で動作するRemixからDurable Objectsを利用する方法を紹介しました。Durable Objectsの利用用途としては少々贅沢な使い方になっていますが使い方のイメージを少しばかり想定いただけたでしょうか。Durable ObjectsはWorkers有料プランかつModule Worker形式でないと利用できませんが、リアルタイム性を重視したアプリケーション、スケールするアプリケーションでは必須となるサービスのひとつになると思います。ぜひRemixとCloudflare Workersの組み合わせを試してみてください。